go堆内存分配 | 您所在的位置:网站首页 › 请简述 go 是如何分配内存的 › go堆内存分配 |
前言
前言
历史沿革
简单的内存分配器
内存分配算法 TCMalloc
go的多级分配
数据结构
三级内存管理
虚拟内存布局 ==> 从对象到页
分配过程
源码分析
go 内存分配器细节补充
逃逸分析
内存模型/happen-before
其它
图解 Go GCmutator 申请内存是以应用视角来看问题,我需要的是某一个 struct,某一个 slice 对应的内存,这与从操作系统中获取内存的接口(比如mmap)之间还有一个鸿沟。需要由 allocator 进行映射与转换,将以“块”来看待的内存与以“对象”来看待的内存进行映射。在现代 CPU 上,我们还要考虑内存分配本身的效率问题,应用执行期间小对象会不断地生成与销毁,如果每一次对象的分配与释放都需要与操作系统交互,那么成本是很高的。这需要在应用层设计好内存分配的多级缓存,尽量减少小对象高频创建与销毁时的锁竞争,这个问题在传统的 C/C++ 语言中已经有了解法,那就是 tcmalloc 一文彻底理解Go语言栈内存/堆内存 历史沿革Go内存管理一文足矣内存分配一般有三种方式:静态存储区(根对象、静态变量、常量)、栈(函数中的临时局部变量)、堆(malloc、new等);一般最常讨论的是栈和堆,栈的特点可以认为是线性内存,管理简单,分配比堆上更快,栈上分配的内存一般不需要程序员关心。因为堆区是多个线程共用的,所以就需要一套机制来进行分配(考虑内存碎片、公平性、冲突解决); 内存碎片问题。将内存按照块的结构来进行划分,使用链表的方式来管理。 并发冲突问题。常见的方案是使用锁,但是锁则不可避免的带来性能问题;所以有各种各样的方案兼顾性能和碎片化以及预分配的策略来进行内存分配。解决思路 分块 提前将内存分块 对象分配:根据对象大小,选择最合适的块返回。 缓存 简单的内存分配器如果想在heap上分配更多的空间,只需要请求系统由低像高移动brk指针,并把对应的内存首地址返回,释放内存时,只需要向下移动brk指针即可。在Linux和unix系统中,我们这里就调用sbrk()方法来操纵brk指针: sbrk(0)获取当前brk的地址 调用sbrk(x),x为正数时,请求分配x bytes的内存空间,x为负数时,请求释放x bytes的内存空间假设我们现在申请了两块内存,A/B,B在A的后面,如果这时候用户想将A释放,这时候brk指针在B的末尾处,那么如果简单的移动brk指针,就会对B进行破坏,所以对于A区域,我们不能直接还给操作系统,而是等B也同时被释放时再还给操作系统,同时也可以把A作为一个缓存,等下次有小于等于A区域的内存需要申请时,可以直接使用A内存,也可以将AB进行合并来统一分配。 所以将内存按照块的结构来进行划分,使用链表的方式来管理,那么除了本身用户申请的内存区域外,还需要一些额外的信息来记录块的大小、下一个块的位置,当前块是否在使用。 为了支持多线程并发访问内存,使用全局锁。到目前为止 通过加锁保证线程安全 通过链表的方式管理内存块,并解决内存复用问题。 free时,首先要看下需要释放的内存是否在brk的位置,如果是,则直接还给操作系统,如果不是,标记为空闲,以后复用。但这个内存分配器也存在几个严重的问题: 全局锁在高并发场景下会带来严重性能问题 每次从头遍历也存在一些性能问题 内存碎片问题,我们内存复用时只是简单的判断块内存是否大于需要的内存区域,如果极端情况下,我们一块空闲内存为1G,而新申请内存为1kb,那就造成严重的碎片浪费 内存释放存在问题,只会把末尾处的内存还给操作系统,中间的空闲部分则没有机会还给操作系统。 内存分配算法 TCMalloc主要是以下几个思想: 划分内存分配粒度,先将内存区域以最小单位定义出来,然后区分对象大小分别对待。小对象分为若干类,使用对应的数据结构来管理,降低内存碎片化。 垃圾回收及预测优化:释放内存时,能够合并小内存为大内存,根据策略进行缓存,下次可以直接复用提升性能。达到一定条件释放回操作系统,避免长期占用导致内存不足。 优化多线程下的性能:针对多线程每个线程有自己独立的一段堆内存分配区。线程对这片区域可以无锁访问,提升性能。PS:就像每个线程有一个独立的栈区一样在 TCMalloc(Thread Cache Memory alloc) 18张图解密新时代内存分配器TCMalloc基本概念 Page,操作系统是按Page管理内存的,,TCMalloc也是这样,只不过TCMalloc里的Page大小与操作系统里的大小并不一定相等,而是倍数关系。 Span 和 SpanList,一组连续的Page被称为Span,持有相同数量Page的Span构成一个双向链表SpanList。Span是TCMalloc中内存管理的基本单位。 Object,一个Span会被按照某个大小拆分为N个Objects,同时这N个Objects构成一个FreeListTCMalloc三层逻辑架构 ThreadCache:线程缓存。 每个线程各自的Cache,一个Cache包含多个空闲内存块链表,每个链表连接的都是内存块,同一个链表上内存块的大小是相同的。 CentralCache:保存的空闲内存块链表,链表的数量与ThreadCache中链表数量相同,当ThreadCache内存块不足时,可以从CentralCache取,当ThreadCache内存块多时,可以放回CentralCache。由于CentralCache是共享的,所以它的访问是要加锁的。 PageHeap:保存的Span链表,当CentralCache没有内存的时,会从PageHeap取,把1个Span拆成若干内存块,添加到对应大小的链表中,当CentralCache内存多的时候,会放回PageHeap。 go的多级分配Go 的内存分配器基于 Thread-Cache Malloc (tcmalloc) ,tcmalloc 为每个线程实现了一个本地缓存, 区分了小对象(小于 32kb)和大对象分配两种分配类型,其管理的内存单元称为 span。但与 tcmalloc 存在一定差异。 比TCMalloc更加细致的划分对象等级 将TCMalloc中针对线程的缓存变更为绑定到逻辑处理器P上的缓存区域。 Go 语言被设计为没有显式的内存分配与释放, 完全依靠编译器与运行时的配合来自动处理,因此也就造就了内存分配器、垃圾回收器两大组件。我们可以将内存分配的路径与 CPU 的多级缓存作类比,这里 mcache 内部的 tiny 可以类比为 L1 cache,而 alloc 数组中的元素可以类比为 L2 cache,全局的 mheap.mcentral 结构为 L3 cache,mheap.arenas 是 L4,L4 是以页为单位将内存向下派发的,由 pageAlloc 来管理 arena 中的空闲内存。如果 L4 也没法满足我们的内存分配需求,那我们就需要向操作系统去要内存了。 在 Go 语言中,根据对象中是否有指针以及对象的大小,将内存分配过程分为三类: tiny :size < 16 bytes && has no pointer(noscan); PS:noscan 指对象里不包含指针,所以gc不需要scan它。 small :has pointer(scan) (size >= 16 bytes && size 32 KB。 L1 mcache.tiny tiny 从此开始 L2 mcache.alloc[] small 从此开始 L3 mcache.central 全局的 L4 mcache.arenas large 直接从此开始,以页为单位将内存向下派发的,由 pageAlloc 来管理 arena 中的空闲内存。arenas 是 Go 向操作系统申请内存时的最小单位,每个 arena 为 64MB 大小,在内存中可以部分连续,但整体是个稀疏结构。单个 arena 会被切分成以 8KB 为单位的 page,一个或多个 page 可以组成一个 mspan,每个 mspan 可以按照 sizeclass 再划分成多个 element。同样大小的 mspan 又分为 scan 和 noscan 两种,分别对应内部有指针的 object 和内部没有指针的 object。 数据结构普通应用程序是调用 malloc 或者 mmap,向 OS 申请内存;而 Go 程序是通过 Go 运行时申请内存,Go 运行时会向 OS 申请一大块内存,然后自己进行管理。Go 应用程序分配内存时是直接找 Go 运行时,这样 Go 运行时才能对内存空间进行跟踪,最后做好内存垃圾回收的工作。Go 运行时把这个大块内存称为 arena 区域,其中又划分为 8KB 大小页。 在 Go 的内存管理机制中,有几个重要的数据结构需要关注,分别是 mspan、heapArena、mcache、mcentral 以及 mheap。其中,mspan 和 heapArena 维护了 Go 的虚拟内存布局,而 mcache、mcentral 以及 mheap 则构成了 Go 的三层内存管理器。 mheap:分配的堆,在页大小为 8KB 的粒度上进行管理。mheap 在 Go 的运行时里边是只有一个实例的全局变量。对应于 TCMalloc 中的 Page heap 结构 heapArena: 可以管理一个区,这个区的大小一般为 64MB mcentral:收集了给定大小等级的所有 span,对应于 TCMalloc 中的 Central cache 结构。作用是为mcache提供切分好的mspan资源,每个spanClass对应一个级别的mcentral; mcache:为 per-P 的缓存。对应于 TCMalloc 中的 Thread cache 结构。mcache 提前从mcentral中获取mspan,后序的分配内存操作就不需要竞争锁。PS:mcentral 和 mcache 都只是 mspan 的容器。 mspan:是 mheap 上管理的一连串的页 ,包含 分配对象的大小规格、占用页的数量等内容。 三级内存管理 在 Go 的三级内存管理器中,维护的对象都是小于 32KB 的小对象。对于这些小对象,Go 又将其按照大小分成了 67 个类别,称为 spanClass/sizeclass。每一个 spanClass 都用来存储固定大小的对象。 class 为 0 时用来管理大于 32KB 对象的 spanClass这些数据都是通过在 runtime.mksizeclasses.go 中计算得到的。Go 在分配的时候,是通过控制每个 spanClass 场景下的最大浪费率,来保障堆内存在 GC 时的碎片率的。 type mcache struct { // Tiny allocator tiny uintptr // 指向当前在使用的 16 字节内存块的地址 tinyoffset uintptr // 指新分配微小对象需要的起始偏移 tinyAllocs uintptr // 存放了多少微小对象 alloc [numSpanClasses]*mspan // spans to allocate from, indexed by spanClass }Golang为每个线程分配了span的缓存,即mcache,每个层级的span都会在mcache中保存一份(macache包含所有规格的span),避免多线程申请内存时不断的加锁。当 mcache 没有可用空间时,从 mcentral 的 mspans 列表获取一个新的所需大小规格的 mspan。 type mcentral struct { lock mutex // 互斥锁 spanclass spanClass // span class ID nonempty mSpanList // non-empty 指还有空闲块的span列表 empty mSpanList // 指没有空闲块的span列表 nmalloc uint64 // 已累计分配的对象个数 // 每种集合都存放两个元素,用来区分集合中 mspan 是否被清理过。 partial [2]spanSet // 包含着空闲空间的 mspan 集合 full [2]spanSet // 不包含空闲空间的 span 集合 }从mcentral数据结构可见,每个mcentral对象只管理特定的class规格的span,事实上每种class都会对应一个mcentral,主要作用是为mcache提供切分好的mspan资源。 Go 使用 mheap 对象管理堆,只有一个全局变量(mheap 也是go gc 工作的地方)。持有虚拟地址空间。mheap 存储了 mcentral 的数组。这个数组包含了各个的 span 规格的 mcentral(mcentral的个数是67x2=134,也是针对有指针和无指针对象分别处理)。由于我们有各个规格的 span 的 mcentral,当一个 mcache 从 mcentral 申请 mspan 时,只需要在独立的 mcentral 级别中使用锁,其它任何 mcache 在同一时间申请不同大小规格的 mspan 互不影响。 当 mcentral 列表为空时,mcentral 从 mheap 获取一系列页用于需要的大小规格的 span。 type mheap struct { lock mutex spans []*mspan bitmap uintptr //指向bitmap首地址,bitmap是从高地址向低地址增长的 arena_start uintptr //指示arena区首地址 arena_used uintptr //指示arena区已使用地址位置 central [67*2]struct { mcentral mcentral pad [sys.CacheLineSize - unsafe.Sizeof(mcentral{})%sys.CacheLineSize]byte } } 虚拟内存布局 ==> 从对象到页操作系统是按page管理内存的,同样Go语言也是也是按page管理内存的,1page为8KB,保证了和操作系统一致。page由 page allocator 管理,pageAlloc在 Go 语言中迭代了多个版本,从简单的 freelist 结构,到 treap 结构,再到现在最新版本的 radix 结构,它的查找时间复杂度也从 O(N) -> O(log(n)) -> O(1)。 从os 拿到的页内存按块管理,空闲块一般由空闲链表来管理:维护一个类似链表的数据结构。当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表。因为分配内存时需要遍历链表,所以它的时间复杂度就是 O(n),为了提高效率,将内存分割成多个链表,每个链表中的内存块大小相同(不同链表不同),申请内存时先找到满足条件的链表,再从链表中选择合适的内存块,减少了需要遍历的内存块数量。 Go 的内存管理基本单元是 mspan,每个 mspan 中会维护着一块连续的虚拟内存空间,内存的起始地址由 startAddr 来记录。每个 mspan 存储的内存空间大小都是内存页的整数倍,由 npages 来保存。Go 的内存页大小设置的是 8KB。 type mspan struct { next *mspan // next span in list, or nil if none prev *mspan // previous span in list, or nil if none startAddr uintptr // address of first byte of span aka s.base() npages uintptr // number of pages in span spanclass spanClass // size class and noscan (uint8) ... allocBits *gcBits // 从 mspan 里分配 element ,就是将 mspan 对应 allocBits 中的对应 bit 位置一 gcmarkBits *gcBits // 实现 span 的颜色标记 }9张图轻松吃透Go内存管理单元Go是按页page8KB为最小单位分配内存的吗?当然不是,如果这样的话会导致内存使用率不高。Go内存管理单元mspan通常由N个且连续的page组成,会把mspan再拆解为更小粒度的单位object。object和object之间构成一个链表(FreeList),object的具体大小由sizeclass决定,mspan结构体上维护一个sizeclass的字段(实际叫spanclass)。PS:mspan通常由N个且连续的page组成,所以可以视为一段连续内存,内部又按统一大小的object 分配,所以可以认为:mspan是 npages 整存,object 零取。 所谓申请内存,是申请 size 大小的内存,参数是size。 ThreadCache 和 TransferCacheManager 维护了 特定几个大小的 object,要做的事情就是个根据size 快速从合适的链表选择空闲内存块/object。 mspan 关键字段 next、prev、list, mspan之间可以构成链表 startAddr,mspan内存的开始位置,N个连续page内存的开始位置 npages,mspan由几page组成 freeindex,空闲object链表的开始位置 nelems,一共有多少个object spanclass,决定object的大小、以及当前mspan是否需要垃圾回收扫描 allocBits,从 mspan 里分配 element 时,我们只要将 mspan 中对应该 element 位置的 bit 位置一就可以了,其实就是将 mspan 对应 allocBits 中的对应 bit 位置一。heapArena 的结构相当于 Go 的一个内存块,在 x86-64 架构下的 Linux 系统上,一个 heapArena 维护的内存空间大小是 64MB。该结构中存放了 ArenaSize/PageSize 长度的 mspan 数组,heapArena 结构的 spans 变量,用来精确管理每一个内存页。而整个 arena 内存空间的基址则存放在 zeroedBase 中。heapArena 结构的部分定义如下: type heapArena struct { ... spans [pagesPerArena]*mspan zeroedBase uintptr }Go 整体的虚拟内存布局是存放在 mheap 中的一个 heapArena 的二维数组。定义如下: type mheap struct { ... arenas [1 p ==> mcache ==> mspan ==> memory block ==> return pointer. type p struct { id int32 mcache *mcache pcache pageCache ... } go 内存分配器细节补充 // go:noinline func smallAllocation() *smallStruct { return &smallStruct{} } // &smallStruct{} 对应汇编代码 LEAQ type."".smallStruct(SB), AX MOVQ AX, (SP) PCDATA $1, $0 CALL runtime.newobject(SB)堆上所有的对象都会通过调用 runtime.newobject 函数分配内存,runtime.newobject 就是内存分配的核心入口,该函数会调用 runtime.mallocgc 分配指定大小的内存空间。 if size 会被放到 spanClass 为 2 的 mspan 中 } else { // 小对象分配 ==> 依次向三级内存管理器请求内存 } } else { // 大对象分配 ==> Go 并不会走上述的三次内存管理器,而是直接通过调用 mcache.allocLarge 来分配大内存。 } // src/runtime/malloc.go func newobject(typ *_type) unsafe.Pointer { return mallocgc(typ.size, typ, true) } // Allocate an object of size bytes. // Small objects are allocated from the per-P cache's free lists. // Large objects (> 32 kB) are allocated straight from the heap. func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer { ... mp := acquirem() var c *mcache if mp.p != 0 { c = mp.p.ptr().mcache // 获取当前的 G所属的P } else { c = mcache0 } var span *mspan if size |
CopyRight 2018-2019 实验室设备网 版权所有 |